Skip to content

atelet: emit per-actor usage events from the stats sweep - #1206

Merged
Jeff Luo (JeffLuoo) merged 6 commits into
agent-substrate:mainfrom
baizhenyu:atelet-stats-events
Sep 9, 2026
Merged

atelet: emit per-actor usage events from the stats sweep#1206
Jeff Luo (JeffLuoo) merged 6 commits into
agent-substrate:mainfrom
baizhenyu:atelet-stats-events

Conversation

@baizhenyu

@baizhenyu Tim Bai (baizhenyu) commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Part of #896 (Phase 1 of #550): the events channel — per-actor usage samples as structured log events. With this, both halves of #174's cardinality split exist: template-level metrics in the TSDB (#961), and everything carrying actor/atespace identity here, in the log store.

The events

One JSON record per executing actor per sweep on atelet's stdout, riding the poller's existing probe — no extra RPC load, and one knob governs both channels (--actor-stats-poll-interval 0 disables the subsystem). Idle workers emit nothing: an idle fleet is silent by design.

Records use the same label vocabulary as actorlog's lifecycle events (ateattr.ActorLogLabels, including the GCE logging.googleapis.com/labels spelling) — so one Cloud Logging filter on labels."ate.actor.uid" returns an actor's container logs, lifecycle transitions, and usage samples interleaved. Identity comes solely from each sample's echo, per the stats RPCs' attribution contract. Measurements ride as payload fields (kind, class, source, the four numbers, observed_at_unix_nano); the kind field (today always periodic) lets future kinds join without reshaping the record.

Events also carry the ate.workerpool.namespace/name pair the metric labels already carry, resolved by the sweep's own pod list, so a pool-level metric spike can pivot to the actors behind it — pool membership lives on the worker pod and is unrecoverable from logs once the pod is gone, so it must be stamped at emission. An unresolved pod emits without the pair, following the metric channel's rule.

Scope

Earlier revisions also took first/final lifecycle samples from the Run/Restore/Checkpoint handlers. Per the review discussion on suspend/resume latency, those are descoped from this PR: it now touches no lifecycle path at all. The bracket design (including taking the final sample inside ateom's CheckpointWorkload and echoing it in the response) moves to a follow-up under #896.

Isolation

The poller dials its own short-lived connection per probe (dialAteomStats) and never touches the lifecycle RPCs' cached clients — after review on #961 the isolation is structural.

Validated live on ate-dev

Periodic events read back from Cloud Logging via labels."ate.actor.name", one per executing actor per minute, with ate.actor.uid confirmed promoted into LogEntry.labels (filterable) — the claim only production could prove.

Consumer note: aggregate freely in the log store, but a log-based metric built over these events must label only by the bounded set (template, class, source, pool) — promoting actor identity into a metric label would reintroduce exactly the cardinality #174 keeps out of the TSDB.

Part of #896. Part of #550.

@JeffLuoo Jeff Luo (JeffLuoo) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update docs/observability.md in the logging section to include instructions for using the new per-actor usage events?

@baizhenyu

Tim Bai (baizhenyu) commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

docs/observability.md

We can update doc in a separate PR after implementation is finalized and submitted. Otherwise, we will need to maintain sync of implementation and doc which is not very efficient.

@git286 Da Huang (git286) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actor Suspend -> Resume latency (sub-second) is one of the most important performance SLO the project is trying to achieve. Right now the sampleFirst sit in the critical path synchronously and could impact the latency in the unhappy case (I see the timeout is 10 seconds for worst case).

I think we should make the sampling async to reduce the impact on the critical path latency.

@baizhenyu

Copy link
Copy Markdown
Collaborator Author

Right now the sampleFirst sit in the critical path synchronously

Done in 91e3fff: sampleFirst now samples from its own goroutine on a context.WithoutCancel context — the Run/Restore handlers return immediately, so the resume SLO pays nothing for telemetry, fast path or worst case. The sampler's own 10s timeout still bounds the detached read.

sampleFinal deliberately stays synchronous: its ordering against CheckpointWorkload is the feature (sampling after the dispatch would race the epoch's own end), and the suspend path carries no sub-second SLO. Both doc comments now state this asymmetry, and the tests cover the async emission and that the detached context still carries a deadline.

@git286

Da Huang (git286) commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

I would double check with Benjamin Elder (@BenTheElder) to make sure that the synchronous sampling logic in the running -> suspend path is acceptable. (Happy case few ms, but worst case 10s latency?)

(Given the first sampling logic has become async so suspend -> running latency won't be affected)

Comment thread cmd/atelet/statsevents.go
Comment thread cmd/atelet/statsevents.go Outdated
@BenTheElder

Copy link
Copy Markdown
Collaborator

I would double check with Benjamin Elder (@BenTheElder) to make sure that the synchronous sampling logic in the running -> suspend path is acceptable. (Happy case few ms, but worst case 10s latency?)

Yikes, 10s in the hot path is way outside of our targets, what other options did we consider?

@baizhenyu

Copy link
Copy Markdown
Collaborator Author

I would double check with Benjamin Elder (@BenTheElder) to make sure that the synchronous sampling logic in the running -> suspend path is acceptable. (Happy case few ms, but worst case 10s latency?)

Yikes, 10s in the hot path is way outside of our targets, what other options did we consider?

The final sample has to be a blocking call, otherwise, it potentially gets lost forever (cgroup got destroyed). However, we can decrease the lifecycleSampleTimeout to 1s or even less. For the happy case, it should only take X milliseconds.

@BenTheElder

Copy link
Copy Markdown
Collaborator

The final sample has to be a blocking call, otherwise, it potentially gets lost forever (cgroup got destroyed). However, we can decrease the lifecycleSampleTimeout to 1s or even less. For the happy case, it should only take X milliseconds.

So in theory we want say, 100ms, but realistically we are something at least XXXms for gVisor operations, and slower for uVM at the moment. Slowing that down in any way is going the wrong direction as we're already pretty far from the desired latency.

Can we just sample while it's online and not sample during snapshot etc.

@baizhenyu

Copy link
Copy Markdown
Collaborator Author

The final sample has to be a blocking call, otherwise, it potentially gets lost forever (cgroup got destroyed). However, we can decrease the lifecycleSampleTimeout to 1s or even less. For the happy case, it should only take X milliseconds.

So in theory we want say, 100ms, but realistically we are something at least XXXms for gVisor operations, and slower for uVM at the moment. Slowing that down in any way is going the wrong direction as we're already pretty far from the desired latency.

Can we just sample while it's online and not sample during snapshot etc.

The periodic resource event emission during normal operation has already been implemented in this PR. However, the final resource utilization event is very important before actor suspend. This event contains the metric of total CPU usage of current actor session. The periodic resource event is unable to provide this information accurately.

Alternatively, we can make this operation async (best effort) so that it does not block the hot path. WDYT?

@git286

Copy link
Copy Markdown
Collaborator

I don't think async works here. The whole point of the final sample is that it reads the CPU counter before the checkpoint destroys it. If we fire-and-forget, the read races the teardown and loses most of the time, so we'd ship the feature with its main guarantee broken.

Correct me if I am wrong, but I think we don't have to choose between latency and the sample. The Checkpoint handler already does a bunch of prep before dispatching to the ateom (read sandbox record, ensure assets, dial, build spec). We can start the sample in a goroutine at the top of the handler and join it right before dispatch. The read takes single-digit ms and the prep takes way longer, so in the happy case it's already done by the time we need it with zero added latency, not just "small". If it's somehow still not done at dispatch time, we wait a short grace (say 50ms), then log the lost-sample warning and proceed. So the worst case drops from 10s to 50ms, and only when the stats path is already broken.

Longer term the clean fix is to have ateom take the sample itself as the first step of CheckpointWorkload and return it in the response, that's literally "sample while it's online" with zero added latency and no race at all. But it's a proto change touching both runtimes, so I'd do that as a follow-up.

WDYT about overlap + short grace in this PR, piggyback as follow-up?

@baizhenyu

Copy link
Copy Markdown
Collaborator Author

I don't think async works here. The whole point of the final sample is that it reads the CPU counter before the checkpoint destroys it. If we fire-and-forget, the read races the teardown and loses most of the time, so we'd ship the feature with its main guarantee broken.

Correct me if I am wrong, but I think we don't have to choose between latency and the sample. The Checkpoint handler already does a bunch of prep before dispatching to the ateom (read sandbox record, ensure assets, dial, build spec). We can start the sample in a goroutine at the top of the handler and join it right before dispatch. The read takes single-digit ms and the prep takes way longer, so in the happy case it's already done by the time we need it with zero added latency, not just "small". If it's somehow still not done at dispatch time, we wait a short grace (say 50ms), then log the lost-sample warning and proceed. So the worst case drops from 10s to 50ms, and only when the stats path is already broken.

Longer term the clean fix is to have ateom take the sample itself as the first step of CheckpointWorkload and return it in the response, that's literally "sample while it's online" with zero added latency and no race at all. But it's a proto change touching both runtimes, so I'd do that as a follow-up.

WDYT about overlap + short grace in this PR, piggyback as follow-up?

The overlap with a short grace period makes sense to me, but I have reservations about the long-term approach. I considered moving the final sampling to ateom before; however, if our goal is zero overhead on checkpoint operations, blocking the process to collect metrics will inevitably introduce latency—regardless of whether it's handled in atelet via a dedicated RPC or embedded directly into the checkpoint RPC via ateom.

@git286

Copy link
Copy Markdown
Collaborator

I don't think async works here. The whole point of the final sample is that it reads the CPU counter before the checkpoint destroys it. If we fire-and-forget, the read races the teardown and loses most of the time, so we'd ship the feature with its main guarantee broken.
Correct me if I am wrong, but I think we don't have to choose between latency and the sample. The Checkpoint handler already does a bunch of prep before dispatching to the ateom (read sandbox record, ensure assets, dial, build spec). We can start the sample in a goroutine at the top of the handler and join it right before dispatch. The read takes single-digit ms and the prep takes way longer, so in the happy case it's already done by the time we need it with zero added latency, not just "small". If it's somehow still not done at dispatch time, we wait a short grace (say 50ms), then log the lost-sample warning and proceed. So the worst case drops from 10s to 50ms, and only when the stats path is already broken.
Longer term the clean fix is to have ateom take the sample itself as the first step of CheckpointWorkload and return it in the response, that's literally "sample while it's online" with zero added latency and no race at all. But it's a proto change touching both runtimes, so I'd do that as a follow-up.
WDYT about overlap + short grace in this PR, piggyback as follow-up?

The overlap with a short grace period makes sense to me, but I have reservations about the long-term approach. I considered moving the final sampling to ateom before; however, if our goal is zero overhead on checkpoint operations, blocking the process to collect metrics will inevitably introduce latency—regardless of whether it's handled in atelet via a dedicated RPC or embedded directly into the checkpoint RPC via ateom.

One thing that hasn't come up in this thread: the latency question aside, there's a correctness bug in the current ordering that the ateom approach would fix for free. Right now we emit the final sample before calling CheckpointWorkload. If the checkpoint fails transiently, the workload keeps running and the control plane retries so we emit a second final for the same session. Anyone summing finals now double counts almost the whole session, and the events carry no epoch/attempt id to dedup on.

If ateom takes the sample as part of CheckpointWorkload and returns it in the response, this problem can't happen: we only emit when the checkpoint actually succeeded, so it's always exactly one final per session. Same trick would work for Terminate, which currently emits no final at all.

So I'd frame the follow-up as a correctness fix, not a latency optimization. On the latency concern: the read is cheap on gVisor, and ateom can overlap it with its own pre-destructive prep, same as what we're doing here. So it doesn't have to add anything to the critical path.

@baizhenyu

Copy link
Copy Markdown
Collaborator Author

I don't think async works here. The whole point of the final sample is that it reads the CPU counter before the checkpoint destroys it. If we fire-and-forget, the read races the teardown and loses most of the time, so we'd ship the feature with its main guarantee broken.
Correct me if I am wrong, but I think we don't have to choose between latency and the sample. The Checkpoint handler already does a bunch of prep before dispatching to the ateom (read sandbox record, ensure assets, dial, build spec). We can start the sample in a goroutine at the top of the handler and join it right before dispatch. The read takes single-digit ms and the prep takes way longer, so in the happy case it's already done by the time we need it with zero added latency, not just "small". If it's somehow still not done at dispatch time, we wait a short grace (say 50ms), then log the lost-sample warning and proceed. So the worst case drops from 10s to 50ms, and only when the stats path is already broken.
Longer term the clean fix is to have ateom take the sample itself as the first step of CheckpointWorkload and return it in the response, that's literally "sample while it's online" with zero added latency and no race at all. But it's a proto change touching both runtimes, so I'd do that as a follow-up.
WDYT about overlap + short grace in this PR, piggyback as follow-up?

The overlap with a short grace period makes sense to me, but I have reservations about the long-term approach. I considered moving the final sampling to ateom before; however, if our goal is zero overhead on checkpoint operations, blocking the process to collect metrics will inevitably introduce latency—regardless of whether it's handled in atelet via a dedicated RPC or embedded directly into the checkpoint RPC via ateom.

One thing that hasn't come up in this thread: the latency question aside, there's a correctness bug in the current ordering that the ateom approach would fix for free. Right now we emit the final sample before calling CheckpointWorkload. If the checkpoint fails transiently, the workload keeps running and the control plane retries so we emit a second final for the same session. Anyone summing finals now double counts almost the whole session, and the events carry no epoch/attempt id to dedup on.

If ateom takes the sample as part of CheckpointWorkload and returns it in the response, this problem can't happen: we only emit when the checkpoint actually succeeded, so it's always exactly one final per session. Same trick would work for Terminate, which currently emits no final at all.

So I'd frame the follow-up as a correctness fix, not a latency optimization. On the latency concern: the read is cheap on gVisor, and ateom can overlap it with its own pre-destructive prep, same as what we're doing here. So it doesn't have to add anything to the critical path.

Updated in the latest commits — the final sample now follows the overlap shape proposed above, tightened one step further:

  • beginFinalSample starts the read at the top of the Checkpoint handler, so it runs concurrently with the handler's own prep (sandbox record, asset ensure, dial, spec build) and is done long before it's needed.
  • joinBeforeCheckpoint runs immediately before dispatching CheckpointWorkload: it waits at most 50ms, and a read still in flight is cancelled, not left racing the teardown. By dispatch time the sample is deterministically complete or abandoned — never pending.
  • emitAfterSuccess publishes only once the ateom confirms the checkpoint. This also fixes an ordering bug in the previous revision: a transiently failed, retried checkpoint would have emitted a duplicate final per attempt, double-counting the session for anyone summing finals. Now every session gets exactly one final, from the attempt that succeeded.

Net latency: zero added in the happy case; worst case 50ms, paid only when the stats path is already broken. The read timeout (500ms) bounds only a background goroutine, never the handler.

On the ateom-side follow-up: with emit-on-success in atelet, the duplicate-final correctness issue is fixed here, so what the proto change would still buy is exact-at-freeze accuracy (the current design undercounts by the CPU burned between read and freeze — prep-length on a session-length measurement) and dropping the 50ms grace. The Terminate gap doesn't need it either: the same begin/join/emit pattern fits the Terminate handler as a small follow-up.

The events channel is the per-actor half of the usage telemetry split:
the poller's metrics aggregate to the bounded template-level label set,
and everything carrying actor or atespace identity travels here
instead, as structured log events -- never a TSDB series.

Each executing actor's sample from the poller's existing sweep becomes
one JSON record on atelet's stdout, using the same label vocabulary as
actorlog's lifecycle events (ateattr.ActorLogLabels, including the GCE
logging.googleapis.com/labels spelling), so one log filter on the actor
uid returns an actor's container logs, lifecycle transitions, and usage
samples interleaved. Identity comes solely from each sample's echo, per
the stats RPCs' attribution contract; the measurements ride as payload
fields, stamped with an event kind so future kinds can join without
reshaping the record. Idle workers emit nothing: an idle fleet is
silent by design.

Events also carry the ate.workerpool.namespace/name pair the metric
labels already carry, resolved by the sweep's own pod list, so a
pool-level metric spike can pivot to the actors behind it -- pool
membership lives on the worker pod and is unrecoverable from logs once
the pod is gone, so it must be stamped at emission. An unresolved pod
emits without the pair, following the metric channel's rule.

The sweep feeds both channels from the same probe, so the events add no
RPC load, and the one knob governs both: --actor-stats-poll-interval 0
disables the subsystem.
@baizhenyu Tim Bai (baizhenyu) changed the title atelet: emit per-actor usage events, bracketed by lifecycle samples atelet: emit per-actor usage events from the stats sweep Sep 3, 2026
@baizhenyu

Copy link
Copy Markdown
Collaborator Author

Updated the PR, removed the lifecycle event and only keep periodic ones.

Comment thread cmd/atelet/main.go Outdated
Comment thread cmd/atelet/statspoller.go Outdated
…azily

Two emitter-construction fixes from review.

Usage events are a data feed, not leveled diagnostics: quieting a node
with --log-level=warn must not silently sever them, so the emitter now
writes through its own fixed-level handler instead of the serverboot
logger, and the subsystem's one off-switch stays
--actor-stats-poll-interval=0. The records still carry level INFO on
the wire, so nothing downstream changes.

metadata.OnGCE probes the metadata server -- seconds of timeout off GCE
-- and was called synchronously on atelet's boot path just to pick the
label-group key. The key now resolves once, at first emit, on the
poller's sweep goroutine, which nobody waits on.
Comment thread cmd/atelet/statspoller.go Outdated
Comment thread cmd/atelet/statsevents.go Outdated
Two follow-ups on the usage-event emitter from review.

The label group's spelling was chosen in two places -- actorlog picked
it for container logs and lifecycle events, the usage emitter picked it
again for itself. Export actorlog.LabelsKey as the one place the choice
lives, so every emitter of the actor-identity label group promotes into
Cloud Logging the same way, and going vendor-neutral later means
changing one function.

The lazy resolution moved the metadata probe off atelet's boot path but
onto the first emit. Warm it from startStatsPoller on a throwaway
goroutine instead: sync.OnceValue lets an emit that arrives first
simply wait for the in-flight probe, so neither the boot path nor the
sweep pays for it.
@baizhenyu Tim Bai (baizhenyu) added kind/feature An enhancement / feature request or implementation area/observability labels Sep 4, 2026
# Conflicts:
#	cmd/atelet/statspoller_test.go
@git286

Copy link
Copy Markdown
Collaborator

One robustness concern: emit writes to stdout synchronously inside the sweep's errgroup, and a write to a full pipe blocks forever, since there's no timeout on stdout writes. So if the log consumer stalls (disk full, log rotation stuck), one stuck write wedges g.Wait(), the tick loop stops, and the metrics freeze too. They'll keep re-serving the last snapshot with no error. Before this PR the sweep never wrote to stdout, so log-pipe health and metric health were independent; this couples them, and it bites exactly during disk incidents when you'd be looking at these dashboards.

Suggestion: make the emitter non-blocking. A small bounded channel drained by one writer goroutine, dropping and counting when full, would do it. Dropping is safe here since these are cumulative samples: the next healthy tick repairs the gap. Losing events when stdout is dead is unavoidable anyway; losing the metrics with them isn't.

Comment thread cmd/atelet/statsevents.go
Two review findings on the events channel.

The emitter wrote to stdout synchronously inside the sweep's errgroup,
and a write to a full pipe blocks forever: one stalled log consumer
(wedged rotation, disk-full fallout) would park a probe, wedge the
sweep, and silently freeze the metrics channel that shares it -- the
gauges would keep re-serving the last snapshot with no error, during
exactly the incident those dashboards exist for. Before the events
channel the sweep performed no stdout writes (its own logging is
debug-level and the metrics leave via OTLP), so log-pipe health and
metric health were independent; an asyncWriter restores that: a bounded
queue drained by one goroutine, dropping and counting when full, with
the stall reported once the stream proves itself live again. Dropping
is safe because the samples are point-in-time readings the next healthy
tick repairs; when stdout is dead the events are lost either way -- the
choice is whether the metrics die with them.

The pool resolver listed pods by the ate.dev/worker-pool key's
presence, and an existence selector matches empty-valued labels too --
anyone can stamp a bare key in YAML -- so a half pair (namespace set,
name empty) could enter the map and emit an empty-string
ate.workerpool.name label, on events and metrics both. Half a pair
names no pool: it is now skipped at ingestion, the resolver being the
sole producer of refs, so absent and unresolvable are the same
unlabeled answer everywhere downstream.
@baizhenyu

Copy link
Copy Markdown
Collaborator Author

Suggestion: make the emitter non-blocking. A small bounded channel drained by one writer goroutine, dropping and counting when full, would do it.

Done in 32549be, as suggested: emit now writes through an asyncWriter — bounded queue (256 records, sized to a sweep's burst), one drain goroutine, select-with-default on the way in so the sweep can never block on the pipe. Drops are counted and reported in one warning on the first successful write afterwards, i.e. once the stream has proven itself live enough for the warning to actually land. That restores the pre-PR invariant that the sweep performs no operation that can wait on the log consumer — events degrade to drop-and-count (the next healthy tick re-samples), and the metrics keep their independent OTLP path through a stall.

Tests cover the contract directly: a wedged underlying writer with a burst past capacity (every write returns immediately, drop count exact, everything not dropped drains on recovery — conservation-checked) and that queued records are copies, since slog reuses its buffer.

@git286

Copy link
Copy Markdown
Collaborator

The async queue fixes blocking, but the logger and this drain goroutine are still two uncoordinated writers on stdout. Today that works only because every record stays under PIPE_BUF (4 KB), which is an accident of current field sizes, not a guarantee. Add a field or point stdout at a file and lines can tear.

Suggest wrapping stdout in actorlog.NewSyncedWriter once, handing it to serverboot.InitLoggerWithWriter and to newAsyncWriter, the same way ateom-gvisor and ateom-microvm already do. The queue keeps the sweep off the critical path, and the lock makes interleaving impossible regardless of size or destination.

The runtime logger and the usage-event drain were two uncoordinated
writers on stdout, tear-free only while every record fits a pipe's
atomic-write size -- an accident of field sizes, not a contract. One
actorlog.SyncedWriter now fronts stdout for both, the same pattern the
ateoms use for their actor-log forwarders. The asyncWriter's queue
still keeps a stalled log consumer from costing the sweep anything; the
shared lock only guarantees whole records.

The drop report also moves onto the emitter's own fixed-level pipeline:
the loss signal is part of the feed's integrity, so it must be exactly
as unkillable by --log-level as the feed itself -- routing it through
the leveled logger meant a node quieted to error would lose the only
evidence that events were dropped.

Also from review of the current shape: document the actual bounds on
the labels-key metadata probe (2s typical worst off GCE, 5s
pathological cap, first-tick emits only), rename the asyncWriter
receiver left over from its earlier name, give the writer tests
cancelable contexts, and fix comments that had drifted from the
implementation -- dialAteomStats served "both telemetry paths" until
the lifecycle sampler was descoped, and the queue-depth comment
oversized its own claim.
@baizhenyu

Copy link
Copy Markdown
Collaborator Author

Suggest wrapping stdout in actorlog.NewSyncedWriter once, handing it to serverboot.InitLoggerWithWriter and to newAsyncWriter, the same way ateom-gvisor and ateom-microvm already do.

Done in 0fc0089, exactly as suggested: main builds one actorlog.NewSyncedWriter(os.Stdout) and hands it to both serverboot.InitLoggerWithWriter and newAsyncWriter. You're right that the previous arrangement was tear-free only by the accident of records fitting PIPE_BUF — the comment claiming single-Write safety is gone with it. One nuance for the record: this doesn't reintroduce the blocking hazard the asyncWriter removed — under a wedged pipe the drain goroutine blocks on the mutex, but the sweep stays behind the queue exactly as before; the lock only guarantees whole records while both channels are live.

Same commit closes a related asymmetry you'd have found next: the drop report was routed through the leveled logger, so --log-level=error would have silenced the only evidence of data loss while the feed itself survived. It now rides the emitter's fixed-level pipeline over the same sink — the loss signal is as unkillable as the feed it reports on (still WARN on the wire, so severity-based alerting is unaffected). The recovery-drain test asserts the report arrives.

@JeffLuoo
Jeff Luo (JeffLuoo) merged commit 721152f into agent-substrate:main Sep 9, 2026
10 of 11 checks passed
@baizhenyu

Copy link
Copy Markdown
Collaborator Author

Could you update docs/observability.md in the logging section to include instructions for using the new per-actor usage events?

Done, as the separate PR discussed above now that the implementation has settled: #1559 adds a Per-Actor Usage Events section to the logging guide — example record, query recipe on the shared label group, per-field semantics (including the epoch scoping of memory_peak_bytes/cpu_usage_usec per source), the sampling knob and delivery contract, and the log-based-metric cardinality rule — plus cross-links from the metrics section. PTAL Jeff Luo (@JeffLuoo)

Jeff Luo (JeffLuoo) pushed a commit that referenced this pull request Sep 9, 2026
Documents the per-actor usage events channel that #1206 added, closing
the docs request from that review.

New `Per-Actor Usage Events` section in the logging guide:
- an example record and the consumer contract (filter on `msg` + `kind`;
identity rides the same label group as lifecycle events and container
logs, so the guide's existing query dimensions apply unchanged, and one
`labels."ate.actor.uid"` filter returns an actor's output, transitions,
and usage interleaved);
- per-field semantics, including the part consumers must not get wrong:
`memory_current_bytes`/`memory_working_set_bytes` are point-in-time,
while `memory_peak_bytes`/`cpu_usage_usec` accumulate within an epoch
whose boundary depends on `source` (cgroup restarts on restore,
guest-agent survives it) — so window CPU is the increase between
samples, never a sum;
- the sampling knob (`--actor-stats-poll-interval`) and the delivery
contract (best-effort behind a bounded queue, independent of
`--log-level`);
- the cardinality rule in bold: log-based metrics over these events must
never label by actor identity.

The metrics section now names the `ate.actor.stats.*` instruments in its
registry pointer and cross-links here for per-actor detail.

Every technical claim is checked against the code and the
`WorkloadStatsSample` proto contract (epoch scoping, the
`memory.peak`/Linux 5.19 caveat, trace-context absence, drop-warning
text, flag semantics).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/observability kind/feature An enhancement / feature request or implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants